Skip to content

Conversation

@mohamedelabbas1996
Copy link
Contributor

@mohamedelabbas1996 mohamedelabbas1996 commented Oct 17, 2025

Summary

This update extends the Antenna permissions framework to support both object-level and model-level permissions.
Previously, permissions were checked only at the object level, using the related project of the object. However, certain models (e.g., those with many-to-many relationships to projects or without a direct project relation) require model-level permission checks. This enhancement introduces a hybrid system that gracefully falls back to Django’s model-level permissions when a model is not directly tied to a specific project.

List of Changes

  • Added model-level permission checks

    The framework now detects whether an object is not tied to a specific project (or is linked to multiple projects) and applies model-level permissions.

  • Returned model-level permissions to the frontend

    Exposed user permissions through the user_permissions field in API responses:

    Collection level: exposes create permission.

    Object level: exposes update and delete permissions and other custom actions permissions.

  • Enabled all signed-in users to create projects

    Introduced a global role (AuthorizedUser) that grants model-level create_project permission to every signed-in user.

  • Added a signal to automatically assign the AuthorizedUser role to every newly registered user.

Related Issues

#1006

Detailed Description

Previously, the Antenna permission framework relied solely on object-level checks using project-based permissions . This approach worked well for most models but did not cover cases where models had many-to-many relationships to projects (such as ProcessingService or Pipeline models) or did not belong to a specific project, like the Project model itself when creating new instances. Only superusers were allowed to manage these models that had no direct project association.

With this update, model-level permissions are introduced , enabling a unified and flexible permission system that supports object-level permissions for project-scoped models and model-level permissions for global or multi-project entities. As part of this change, a new global role AuthenticatedUsers has been introduced and is automatically assigned to every signed-in user. This role grants the create_project model-level permission, meaning all authenticated users can now create new projects without requiring superuser privileges or membership in any existing project.

How to Test the Changes

  • Go to the Django admin
  • Click on Groups
  • TBD

Screenshots

image image

Deployment Notes

Apply migration 0079_assign_authorized_user_group.py to assign all existing users to the AuthenticatedUsers group, enabling them to create projects.

Checklist

  • I have tested these changes appropriately.
  • I have added and/or modified relevant tests.
  • I updated relevant documentation or comments.
  • I have verified that this PR follows the project's coding standards.
  • Any dependent changes have already been merged to main.

Summary by CodeRabbit

  • New Features

    • Fine-grained model- and object-level permission system and automatic assignment of an "AuthorizedUser" global role to new users.
  • Improvements

    • Consistent permission checks across APIs and views; serializers support pre-save permission evaluation; role definitions split model- vs. object-level permissions.
  • Tests

    • Extensive permission test coverage for model-, role-, and project-level behaviors.
  • Chores

    • Migrations adding group/permission entries and updating project permission metadata.

@netlify
Copy link

netlify bot commented Oct 17, 2025

Deploy Preview for antenna-preview canceled.

Name Link
🔨 Latest commit c64bd72
🔍 Latest deploy log https://app.netlify.com/projects/antenna-preview/deploys/690394c59c75660008c52f2c

@mohamedelabbas1996 mohamedelabbas1996 changed the title Extend permission framework to support model-level permissions Extend permissions framework to support model-level permissions Oct 17, 2025
@mohamedelabbas1996 mohamedelabbas1996 self-assigned this Oct 17, 2025
@mohamedelabbas1996 mohamedelabbas1996 linked an issue Oct 17, 2025 that may be closed by this pull request
class Meta:
ordering = ["-priority", "created_at"]
permissions = [
# Project permissions
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be moved to base.models.BaseModel.Meta?

You have to explicitly inherit from the class Meta(BaseModel.Meta)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding create_project here makes sense for the Project model! but for other models, they could either go on that model or the BaseModel with a dynamic method.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried moving permissions to the BaseModel, but it turns out Django creates duplicate permissions when they’re inherited one for the BaseModel itself and another for each subclass like Project.
We could instead handle this through a signal that automatically creates model-level permissions for each relevant model at startup.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't mind having all the permissions listed under Project.Meta because I think we will move them to Organization.Meta later. Otherwise, I would say put them each under a relevant model like ProcessingService.Meta

@mohamedelabbas1996 mohamedelabbas1996 marked this pull request as ready for review October 28, 2025 17:27
@mihow mihow requested a review from Copilot October 28, 2025 18:03
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR extends the Antenna permissions framework to support both object-level and model-level permissions, addressing the limitation where only object-level permissions were previously checked using the related project. The enhancement introduces a hybrid system that falls back to Django's model-level permissions when a model is not directly tied to a specific project, and enables all authenticated users to create projects by introducing a new AuthenticatedUsers global role.

Key changes:

  • Added model-level permission checking logic to handle models with many-to-many project relationships or no project relation
  • Introduced GlobalRole base class and AuthenticatedUsers role to grant create_project permission to all authenticated users
  • Added automatic role assignment via post-save signal for new users

Reviewed Changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
ami/users/signals.py Added post_save signal to auto-assign AuthenticatedUsers role to newly created users
ami/users/roles.py Renamed permissions to object_level_permissions, introduced GlobalRole base class and AuthenticatedUsers role
ami/ml/views.py Added ObjectPermission class and permission check before creating ProcessingService instances
ami/ml/tests.py Updated test user to be superuser to maintain test validity
ami/main/tests.py Added comprehensive test coverage for model-level permissions on Project, ProcessingService, and Taxon models
ami/main/models.py Added permission check methods to Project model and renamed custom permission methods for clarity
ami/main/migrations/0080_alter_project_options.py Added model-level permissions to Project model metadata
ami/main/migrations/0079_assign_authenticated_users_group.py Migration to create AuthenticatedUsers group and assign it to all existing users
ami/main/api/views.py Added ObjectPermission class to TaxonViewSet
ami/jobs/views.py Renamed check_custom_permission to check_custom_object_level_permission
ami/jobs/models.py Renamed custom permission methods and updated logging message
ami/base/serializers.py Added get_instance_for_permission_check method to build unsaved instances for permission validation
ami/base/permissions.py Refactored to use new get_permissions method and model's get_collection_level_permissions
ami/base/models.py Added comprehensive model-level and object-level permission infrastructure with proper fallback logic

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

def assign_model_level_permissions(cls, group):
from django.contrib.contenttypes.models import ContentType

ct = ContentType.objects.get_for_model(Project)
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assign_model_level_permissions method hardcodes the content type to Project, which limits the GlobalRole class to only Project permissions. Consider making this more generic by accepting a model class parameter or deriving the content type from the permission codename.

Copilot uses AI. Check for mistakes.
logger.debug(f"Project.check_permission action: {action}")
if action == "create":
return self.check_model_level_permission(user, action)
return super().check_object_level_permission(user, action)
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method calls super().check_object_level_permission(user, action) on line 329, but check_permission is the entry point that should be called. This bypasses the logic in the parent's check_permission method. Call super().check_permission(user, action) instead to ensure proper permission routing.

Suggested change
return super().check_object_level_permission(user, action)
return super().check_permission(user, action)

Copilot uses AI. Check for mistakes.
Comment on lines +336 to +339
cls, user: AbstractUser | AnonymousUser, project: "Project | None" = None
) -> list[str]:
# Use model-level permissions for project collection-level actions
return ["create"] if user.has_perm("main.create_project") else []
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter order differs from the parent class method in ami/base/models.py line 326, which has project before user. This inconsistency could lead to errors when calling the method. Align the parameter order with the base class.

Suggested change
cls, user: AbstractUser | AnonymousUser, project: "Project | None" = None
) -> list[str]:
# Use model-level permissions for project collection-level actions
return ["create"] if user.has_perm("main.create_project") else []
cls, project: "Project | None" = None, user: AbstractUser | AnonymousUser = None
) -> list[str]:
# Use model-level permissions for project collection-level actions
return ["create"] if user and user.has_perm("main.create_project") else []

Copilot uses AI. Check for mistakes.

def check_model_level_permission(self, user: AbstractUser | AnonymousUser, action: str) -> bool:
model = self._meta.model_name
app_label = "main" # Assume all model level permissions are in 'main' app
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoding app_label to 'main' makes this method inflexible for models in other apps. Use self._meta.app_label instead to automatically determine the correct app label.

Suggested change
app_label = "main" # Assume all model level permissions are in 'main' app
app_label = self._meta.app_label # Use the actual app label of the model

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this suggestion, since models in other apps use the BaseModel, the constructed strings will be incorrect. For example:
"create": f"{app_label}.create_{model}"
will be
"create": f"main.create_job" instead of "create": f"jobs.create_job"

return ["update", "delete", "view"]

model = self._meta.model_name
app_label = "main" # self._meta.app_label
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded app_label = 'main' with a commented-out self._meta.app_label suggests this should use the model's actual app label. Use self._meta.app_label to ensure correct permissions are checked for models outside the 'main' app.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree here as well

"""
Retrieve collection-level permissions for the given user.
"""
app_label = "main"
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to previous comments, hardcoding app_label to 'main' limits reusability. Use cls._meta.app_label to derive the app label from the model class.

Suggested change
app_label = "main"
app_label = cls._meta.app_label

Copilot uses AI. Check for mistakes.
Comment on lines +3503 to +3505
def _assign_user_permission_and_reset_caches(
self, user, perm_codename: str, app_label: str = "main", model_name: str = "project"
):
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper method is duplicated in both TestProcessingServiceModelLevelPermissions and TestTaxonModelLevelPermissions classes. Consider extracting it to a shared test base class or utility module to reduce code duplication.

Copilot uses AI. Check for mistakes.
Comment on lines +3527 to +3529
def _remove_user_permission_and_reset_cache(
self, user, perm_codename: str, app_label: str = "main", model_name: str = "project"
):
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper method is also duplicated across test classes. Extract it along with _assign_user_permission_and_reset_caches to a shared location to improve maintainability.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

@mihow mihow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @mohamedelabbas1996, should I see create_project permission added on the right side here? How do I add or remove the permission from a user?

Image

Also I am not seeing the permission in the /me API response. Where is the frontend checking?

Image

@mihow
Copy link
Collaborator

mihow commented Oct 29, 2025

I found it! Under the AuthenticatedUsers group
image

@action(detail=True, methods=["post"])
def register_pipelines(self, request: Request, pk=None) -> Response:
processing_service = ProcessingService.objects.get(pk=pk)
processing_service = self.get_object()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch

serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
instance_before_creation = serializer.get_instance_for_permission_check() # type: ignore
self.check_object_permissions(request, instance_before_creation)
Copy link
Collaborator

@mihow mihow Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this know to check the create permission?

return self.check_model_level_permission(user, action)
return super().check_object_level_permission(user, action)

def get_permissions(self, user: AbstractUser | AnonymousUser) -> list[str]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will you add a comment about why get_permission needs to be overwritten here? Is it because Project has no project accessor?

instance_data = self.get_permissions(instance=instance, instance_data=instance_data)
return instance_data

def get_instance_for_permission_check(self):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is helpful, thanks.

)


class AuthenticatedUsers(GlobalRole):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename this to AuthorizedUser? or ApprovedUser

Authenticated implies that they are logged-in. But I think authorized or approved are what we want here.

Also the other roles are singular instead of plural, so I would stick with that pattern.

Copy link
Collaborator

@mihow mihow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great and comprehensive work @mohamedelabbas1996!

My last big question:

Where do the model level permissions show up in the API? Are they checked by the UI?

@mihow
Copy link
Collaborator

mihow commented Oct 29, 2025

I can confirm that the Create Project button disappears after I remove a new user from the authorized users group, but I just can't see where the model permissions are being added to the API response with my own eyes.

@mohamedelabbas1996
Copy link
Contributor Author

mohamedelabbas1996 commented Oct 30, 2025

Great and comprehensive work @mohamedelabbas1996!

My last big question:

Where do the model level permissions show up in the API? Are they checked by the UI?

Thanks, Michael!

The model-level permissions are returned in the user_permissions field of the API response:
• The create permission appears in the collection-level user_permissions list.
• The other model-level permissions (like update, delete, and custom ones) appear in the object-level user_permissions for each item in the results.

That’s what the frontend uses to determine which actions to show for each model and object.
image

@mohamedelabbas1996
Copy link
Contributor Author

I can confirm that the Create Project button disappears after I remove a new user from the authorized users group, but I just can't see where the model permissions are being added to the API response with my own eyes.

the create permission is returned through the user_permissions field in the Projects list (/projects) API response.
It appears at the collection-level user_permissions list in the response, which is how the frontend determines whether to show the Create Project button.
image

@coderabbitai
Copy link

coderabbitai bot commented Oct 30, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds comprehensive model-, object-, and collection-level permission APIs, applies object-level enforcement to viewsets, introduces a global AuthorizedUser group and signal to auto-assign it, separates model vs object permissions in roles, adds migrations for group and project permissions, and expands tests and serializer helpers for pre-save permission checks.

Changes

Cohort / File(s) Summary
Permission Foundation
ami/base/models.py
Adds model- and object-level permission APIs and helpers: check_permission, check_model_level_permission, check_object_level_permission, check_custom_object_level_permission, get_permissions, get_model_level_permissions, get_custom_model_level_permissions, get_object_level_permissions, get_custom_object_level_permissions, and get_collection_level_permissions (classmethod). Implements project-relation detection and centralized permission resolution.
Permission Aggregation & Helpers
ami/base/permissions.py
Replaces guardian calls with instance.get_permissions(user), removes guardian import, changes logging level in collection permission code, and delegates collection-level create checks to model get_collection_level_permissions.
Serializer helper for pre-save checks
ami/base/serializers.py
Adds DefaultSerializer.get_instance_for_permission_check() to build an unsaved model instance from validated_data (excludes M2M/non-model fields) for permission evaluation before saving.
Project & Model Permission Surface
ami/main/models.py
Adds Project.check_permission, Project.get_permissions, and Project.get_collection_level_permissions; expands Project permission constants and Meta.permissions. Renames custom permission methods on SourceImage to object-level names and adds Identification check_permission for destroy.
Job model/view rename
ami/jobs/models.py, ami/jobs/views.py
Renames job custom-permission methods to object-level names (check_custom_object_level_permission, get_custom_object_level_permissions) and updates call sites (e.g., perform_create) accordingly.
View-level enforcement & changes
ami/main/api/views.py, ami/ml/views.py
Applies permission_classes = [ObjectPermission] to ProcessingService and Taxon viewsets; ProjectViewSet.perform_create now directly saves with owner (removes super call); ProcessingServiceViewSet.create uses instance-for-permission-check and check_object_permissions; register_pipelines now uses self.get_object().
Roles, Global group, and signals
ami/users/roles.py, ami/users/signals.py
Introduces GlobalRole and AuthorizedUser (model-level perms), changes role classes to expose object_level_permissions (separating model-level vs object-level), updates create_roles_for_project to sync/reset group perms, and adds post_save receiver assign_authenticated_users_group to assign AuthorizedUser to new users.
Migrations: AuthorizedUser group & Project options
ami/main/migrations/0079_assign_authorized_user_group.py, ami/main/migrations/0080_alter_project_options.py
Adds data migration to create/assign AuthorizedUser group with create_project perm to all users (with reversible logic); adds AlterModelOptions migration updating Project ordering and a broad set of model-level permissions.
Tests updates
ami/main/tests.py, ami/ml/tests.py
Introduces BasePermissionTestCase and expanded permission-focused test suites (model- and role-based tests), updates expectations (e.g., regular user create_project), and sets a test user to superuser in ml tests.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant View
    participant Serializer
    participant Model
    participant AuthDB

    Client->>View: HTTP request (create/retrieve/update/delete)
    View->>Serializer: validate()
    alt create/update requires pre-check
        Serializer->>Serializer: get_instance_for_permission_check()
        Serializer-->>View: unsaved instance
        View->>View: check_object_permissions(request, instance)
    end
    View->>Model: check_permission(user, action)
    alt instance scoped to Project
        Model->>Model: check_object_level_permission(user, action)
        Model->>AuthDB: resolve object-level perms (user/groups)
        AuthDB-->>Model: object perms
    else model-level permission
        Model->>Model: check_model_level_permission(user, action)
        Model->>AuthDB: resolve model/group perms
        AuthDB-->>Model: model perms
    end
    alt collection CREATE flow
        View->>Model: get_collection_level_permissions(user, project)
        Model-->>View: allowed collection actions
    end
    View-->>Client: 200/403 response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Focus points:
    • Correctness of BaseModel permission resolution and retrieve/view bypass semantics.
    • Migration 0079 performance and correctness when assigning a group to all users and its reversible path.
    • Role/group permission synchronization in create_roles_for_project (clearing vs reapplying permissions).
    • View integration where get_instance_for_permission_check and check_object_permissions are used.
    • Tests: ensure updated expectations align with new permission semantics and caching/reset helpers.

Poem

🐰
A hop, a check, permissions bloom,
Model, object, collection — room by room,
Groups assigned, roles rearranged,
Signals whisper, migrations changed,
A rabbit cheers — the gates are tuned! 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.34% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Description Check ❓ Inconclusive The PR description covers most required template sections well: it provides a clear summary of the model-level and object-level permission framework extension, lists the key changes in bullet format, references the related issue (#1006), includes a detailed explanation of the rationale and approach, provides deployment notes about the migration, includes screenshots, and checks most of the verification checklist items. However, there are two notable gaps: the "How to Test the Changes" section contains only a placeholder ("Go to the Django admin, Click on Groups, TBD") rather than actual testing instructions or references to test coverage, and the first checklist item "I have tested these changes appropriately" is left unchecked, which creates ambiguity about whether the author actually performed manual testing despite checking other test-related items. The author should either provide concrete testing instructions or confirm that existing automated tests cover the changes adequately. Additionally, clarification is needed on whether the unchecked "I have tested these changes appropriately" item represents incomplete manual testing or simply an oversight given that relevant automated tests were added and modified. The description is otherwise well-structured and comprehensive, so these issues can likely be resolved with minor updates.
✅ Passed checks (1 passed)
Check name Status Explanation
Title Check ✅ Passed The pull request title "Extend permissions framework to support model-level permissions" accurately summarizes the primary change in this PR. The changeset introduces comprehensive model-level permission checks to complement existing object-level permission logic across multiple files (ami/base/models.py, ami/main/models.py, ami/users/roles.py, etc.), along with supporting infrastructure like migrations and signal handlers. The title is concise, specific, and clearly conveys the main architectural enhancement without being overly broad or vague.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/model-level-permissions

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (3)
ami/base/models.py (1)

185-198: Fix app label lookup for model-level permissions.

Hardcoding app_label = "main" means the new fallback will never honor Django’s model-level permissions for any BaseModel subclass outside the main app—user.has_perm("main.create_foo") can’t match the real permission (foo.create_foo), so create/update/delete remain blocked for those models. Derive the label from the model metadata instead, and mirror the same fix in the other helpers that build permission strings (get_model_level_permissions, get_custom_model_level_permissions, get_collection_level_permissions).

-        app_label = "main"  # Assume all model level permissions are in 'main' app
+        app_label = self._meta.app_label
ami/main/tests.py (2)

3524-3655: Affected by BasePermissionTestCase bug.

This test class calls the permission helper methods without specifying model_name="processingservice", which means permissions are being associated with the wrong ContentType (Project instead of ProcessingService). See the critical issue raised in the BasePermissionTestCase comment above for the fix.


3657-3799: Affected by BasePermissionTestCase bug.

This test class calls the permission helper methods without specifying model_name="taxon", which means permissions are being associated with the wrong ContentType (Project instead of Taxon). See the critical issue raised in the BasePermissionTestCase comment above for the fix.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6b63064 and aba1839.

📒 Files selected for processing (15)
  • ami/base/models.py (3 hunks)
  • ami/base/permissions.py (2 hunks)
  • ami/base/serializers.py (1 hunks)
  • ami/jobs/models.py (3 hunks)
  • ami/jobs/views.py (1 hunks)
  • ami/main/api/views.py (2 hunks)
  • ami/main/migrations/0079_assign_authorized_user_group.py (1 hunks)
  • ami/main/migrations/0080_alter_project_options.py (1 hunks)
  • ami/main/migrations/0081_alter_project_options.py (1 hunks)
  • ami/main/models.py (3 hunks)
  • ami/main/tests.py (8 hunks)
  • ami/ml/tests.py (1 hunks)
  • ami/ml/views.py (4 hunks)
  • ami/users/roles.py (6 hunks)
  • ami/users/signals.py (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (13)
ami/main/migrations/0080_alter_project_options.py (1)
ami/main/migrations/0079_assign_authorized_user_group.py (1)
  • Migration (85-93)
ami/base/serializers.py (1)
ami/main/api/serializers.py (1)
  • Meta (40-47)
ami/main/migrations/0079_assign_authorized_user_group.py (1)
ami/main/migrations/0080_alter_project_options.py (1)
  • Migration (6-66)
ami/main/api/views.py (1)
ami/base/permissions.py (1)
  • ObjectPermission (77-86)
ami/users/signals.py (1)
ami/users/roles.py (4)
  • AuthorizedUser (170-173)
  • Role (12-52)
  • assign_user (26-34)
  • assign_user (66-73)
ami/jobs/models.py (2)
ami/main/models.py (2)
  • check_custom_object_level_permission (1870-1873)
  • get_custom_object_level_permissions (1875-1892)
ami/base/models.py (2)
  • check_custom_object_level_permission (247-256)
  • get_custom_object_level_permissions (341-351)
ami/main/models.py (1)
ami/base/models.py (9)
  • check_permission (200-212)
  • check_model_level_permission (183-198)
  • check_object_level_permission (214-245)
  • get_permissions (258-270)
  • get_object_level_permissions (320-339)
  • get_collection_level_permissions (354-370)
  • check_custom_object_level_permission (247-256)
  • get_project (135-151)
  • get_custom_object_level_permissions (341-351)
ami/ml/views.py (2)
ami/base/permissions.py (1)
  • ObjectPermission (77-86)
ami/base/serializers.py (1)
  • get_instance_for_permission_check (61-84)
ami/jobs/views.py (3)
ami/main/models.py (1)
  • check_custom_object_level_permission (1870-1873)
ami/base/models.py (1)
  • check_custom_object_level_permission (247-256)
ami/jobs/models.py (1)
  • check_custom_object_level_permission (952-962)
ami/base/models.py (2)
ami/main/models.py (6)
  • check_permission (324-337)
  • check_permission (2202-2215)
  • check_custom_object_level_permission (1870-1873)
  • get_permissions (339-340)
  • get_custom_object_level_permissions (1875-1892)
  • get_collection_level_permissions (343-347)
ami/base/serializers.py (1)
  • get_permissions (46-54)
ami/base/permissions.py (3)
ami/main/models.py (2)
  • get_permissions (339-340)
  • get_collection_level_permissions (343-347)
ami/base/models.py (2)
  • get_permissions (258-270)
  • get_collection_level_permissions (354-370)
ami/base/serializers.py (1)
  • get_permissions (46-54)
ami/users/roles.py (1)
ami/main/models.py (2)
  • Project (225-485)
  • Permissions (349-418)
ami/main/tests.py (5)
ami/ml/views.py (2)
  • status (176-182)
  • create (159-173)
ami/tests/fixtures/main.py (1)
  • setup_test_project (114-131)
ami/main/models.py (7)
  • ProjectManager (173-207)
  • Project (225-485)
  • Permissions (349-418)
  • delete (2169-2200)
  • Taxon (3243-3490)
  • create (176-194)
  • name (1023-1024)
ami/users/roles.py (5)
  • ProjectManager (131-167)
  • assign_user (26-34)
  • assign_user (66-73)
  • BasicMember (96-102)
  • Identifier (109-114)
ami/ml/models/processing_service.py (1)
  • create (28-31)
🪛 Ruff (0.14.2)
ami/main/migrations/0080_alter_project_options.py

7-9: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


11-66: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

ami/base/serializers.py

69-69: Avoid specifying long messages outside the exception class

(TRY003)

ami/main/migrations/0079_assign_authorized_user_group.py

7-7: Unused function argument: schema_editor

(ARG001)


19-19: Use logging.exception instead of logging.error

Replace with exception

(TRY400)


52-52: Unused function argument: schema_editor

(ARG001)


57-57: Local variable Permission is assigned to but never used

Remove assignment to unused variable Permission

(F841)


61-61: Local variable project_ct is assigned to but never used

Remove assignment to unused variable project_ct

(F841)


87-89: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


91-93: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

ami/main/migrations/0081_alter_project_options.py

7-9: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


11-68: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

ami/main/api/views.py

1287-1287: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

ami/users/signals.py

78-78: Unused function argument: sender

(ARG001)


78-78: Unused function argument: kwargs

(ARG001)

ami/main/models.py

344-344: Unused class method argument: project

(ARG003)

ami/ml/views.py

146-146: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

ami/base/models.py

326-326: Consider iterable unpacking instead of concatenation

Replace with iterable unpacking

(RUF005)

ami/users/roles.py

15-15: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


58-58: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


173-173: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

ami/main/tests.py

1289-1289: Possible hardcoded password assigned to argument: "password"

(S106)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: test
🔇 Additional comments (5)
ami/main/tests.py (3)

6-7: LGTM! Appropriate imports for permission testing.

The addition of Permission and ContentType imports is necessary for the new model-level permission test cases and follows Django's permission framework correctly.


1262-1267: LGTM! Test correctly reflects new permission model.

The test rename and assertion update correctly validate that regular authenticated users can now create projects, aligning with the PR's introduction of the AuthenticatedUsers group with create_project permission.


1276-1390: LGTM! Comprehensive role-based permission testing.

This test class provides excellent coverage of the permission framework:

  • Validates both backend Guardian permissions and API-level user_permissions fields
  • Tests all major roles (superuser, owner, manager, members, regular users)
  • Uses clear helper methods to reduce duplication
  • Appropriately verifies collection-level (create) vs object-level (update/delete) permissions
ami/main/api/views.py (2)

178-180: LGTM! Clear documentation of the permission flow.

The updated comments effectively explain how the post_save signal assigns the ProjectManager role when both created=True and owner is set. Explicitly saving with the owner ensures the signal has the necessary context for role assignment, which aligns with the model-level permissions framework introduced in this PR.


1287-1287: LGTM! Correctly adds object-level permission checks.

Adding ObjectPermission to TaxonViewSet aligns with the PR's objective of extending the permissions framework to support model-level permissions. Since Taxon can have many-to-many relationships with projects, applying object-level permission checks ensures proper authorization. This implementation is consistent with other viewsets in the file (e.g., ProjectViewSet, DeploymentViewSet, SourceImageViewSet).

Note: The static analysis hint about typing.ClassVar is a false positive—permission_classes is a standard Django REST Framework class attribute and doesn't require ClassVar annotation.

Comment on lines +3479 to +3522
class BasePermissionTestCase(APITestCase):
"""
Base class for tests that verify model-level permissions.
Provides reusable helpers for assigning and removing user permissions.
"""

def _assign_user_permission_and_reset_caches(
self, user, perm_codename: str, app_label: str = "main", model_name: str = "project"
):
ct = ContentType.objects.get(app_label=app_label, model=model_name)
perm, _ = Permission.objects.get_or_create(
codename=perm_codename,
content_type=ct,
defaults={"name": f"Can {perm_codename.replace('_', ' ')}"},
)

user.user_permissions.add(perm)

# Clear cached permissions
for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]:
if hasattr(user, attr):
delattr(user, attr)
user.refresh_from_db()

return perm

def _remove_user_permission_and_reset_cache(
self, user, perm_codename: str, app_label: str = "main", model_name: str = "project"
):
try:
ct = ContentType.objects.get(app_label=app_label, model=model_name)
perm = Permission.objects.get(codename=perm_codename, content_type=ct)
user.user_permissions.remove(perm)
except Permission.DoesNotExist:
return False

# Clear cached permissions
for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]:
if hasattr(user, attr):
delattr(user, attr)
user.refresh_from_db()

return True

Copy link

@coderabbitai coderabbitai bot Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Default model_name parameter causes incorrect ContentType lookup.

The helper methods default model_name="project", but they're used to assign permissions for other models (ProcessingService, Taxon) without overriding this parameter. This causes ContentType.objects.get(model="project") to be used for all models, associating permissions with the wrong content type.

Impact: Tests may pass, but permissions are associated with the Project model instead of ProcessingService or Taxon models, causing permission checks to fail in production.

Apply this diff to fix the issue by deriving the model name from the permission codename:

 def _assign_user_permission_and_reset_caches(
-    self, user, perm_codename: str, app_label: str = "main", model_name: str = "project"
+    self, user, perm_codename: str, app_label: str = "main", model_name: str | None = None
 ):
+    # Derive model name from permission codename if not explicitly provided
+    # E.g., "create_processingservice" -> "processingservice"
+    if model_name is None:
+        # Remove common action prefixes to get model name
+        for prefix in ["create_", "update_", "delete_", "view_"]:
+            if perm_codename.startswith(prefix):
+                model_name = perm_codename[len(prefix):]
+                break
+        else:
+            # Fallback: use the entire codename as model name
+            model_name = perm_codename
+    
     ct = ContentType.objects.get(app_label=app_label, model=model_name)
     perm, _ = Permission.objects.get_or_create(
         codename=perm_codename,
         content_type=ct,
         defaults={"name": f"Can {perm_codename.replace('_', ' ')}"},
     )

     user.user_permissions.add(perm)

     # Clear cached permissions
     for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]:
         if hasattr(user, attr):
             delattr(user, attr)
     user.refresh_from_db()

     return perm

 def _remove_user_permission_and_reset_cache(
-    self, user, perm_codename: str, app_label: str = "main", model_name: str = "project"
+    self, user, perm_codename: str, app_label: str = "main", model_name: str | None = None
 ):
+    # Derive model name from permission codename if not explicitly provided
+    if model_name is None:
+        for prefix in ["create_", "update_", "delete_", "view_"]:
+            if perm_codename.startswith(prefix):
+                model_name = perm_codename[len(prefix):]
+                break
+        else:
+            model_name = perm_codename
+    
     try:
         ct = ContentType.objects.get(app_label=app_label, model=model_name)
         perm = Permission.objects.get(codename=perm_codename, content_type=ct)
         user.user_permissions.remove(perm)
     except Permission.DoesNotExist:
         return False

     # Clear cached permissions
     for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]:
         if hasattr(user, attr):
             delattr(user, attr)
     user.refresh_from_db()

     return True
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class BasePermissionTestCase(APITestCase):
"""
Base class for tests that verify model-level permissions.
Provides reusable helpers for assigning and removing user permissions.
"""
def _assign_user_permission_and_reset_caches(
self, user, perm_codename: str, app_label: str = "main", model_name: str = "project"
):
ct = ContentType.objects.get(app_label=app_label, model=model_name)
perm, _ = Permission.objects.get_or_create(
codename=perm_codename,
content_type=ct,
defaults={"name": f"Can {perm_codename.replace('_', ' ')}"},
)
user.user_permissions.add(perm)
# Clear cached permissions
for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]:
if hasattr(user, attr):
delattr(user, attr)
user.refresh_from_db()
return perm
def _remove_user_permission_and_reset_cache(
self, user, perm_codename: str, app_label: str = "main", model_name: str = "project"
):
try:
ct = ContentType.objects.get(app_label=app_label, model=model_name)
perm = Permission.objects.get(codename=perm_codename, content_type=ct)
user.user_permissions.remove(perm)
except Permission.DoesNotExist:
return False
# Clear cached permissions
for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]:
if hasattr(user, attr):
delattr(user, attr)
user.refresh_from_db()
return True
class BasePermissionTestCase(APITestCase):
"""
Base class for tests that verify model-level permissions.
Provides reusable helpers for assigning and removing user permissions.
"""
def _assign_user_permission_and_reset_caches(
self, user, perm_codename: str, app_label: str = "main", model_name: str | None = None
):
# Derive model name from permission codename if not explicitly provided
# E.g., "create_processingservice" -> "processingservice"
if model_name is None:
# Remove common action prefixes to get model name
for prefix in ["create_", "update_", "delete_", "view_"]:
if perm_codename.startswith(prefix):
model_name = perm_codename[len(prefix):]
break
else:
# Fallback: use the entire codename as model name
model_name = perm_codename
ct = ContentType.objects.get(app_label=app_label, model=model_name)
perm, _ = Permission.objects.get_or_create(
codename=perm_codename,
content_type=ct,
defaults={"name": f"Can {perm_codename.replace('_', ' ')}"},
)
user.user_permissions.add(perm)
# Clear cached permissions
for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]:
if hasattr(user, attr):
delattr(user, attr)
user.refresh_from_db()
return perm
def _remove_user_permission_and_reset_cache(
self, user, perm_codename: str, app_label: str = "main", model_name: str | None = None
):
# Derive model name from permission codename if not explicitly provided
if model_name is None:
for prefix in ["create_", "update_", "delete_", "view_"]:
if perm_codename.startswith(prefix):
model_name = perm_codename[len(prefix):]
break
else:
model_name = perm_codename
try:
ct = ContentType.objects.get(app_label=app_label, model=model_name)
perm = Permission.objects.get(codename=perm_codename, content_type=ct)
user.user_permissions.remove(perm)
except Permission.DoesNotExist:
return False
# Clear cached permissions
for attr in ["_perm_cache", "_user_perm_cache", "_group_perm_cache"]:
if hasattr(user, attr):
delattr(user, attr)
user.refresh_from_db()
return True
🤖 Prompt for AI Agents
In ami/main/tests.py around lines 3479-3522, the helper methods default
model_name="project" which causes ContentType lookups to always use the Project
model; change the signature of both helpers to default model_name=None and, if
model_name is None, derive it from the perm_codename by taking the last segment
after splitting on '_' (e.g., perm_codename.split('_')[-1].lower()), then use
that derived model_name for ContentType.objects.get(...); keep the rest of the
logic (creating/getting Permission, adding/removing, clearing caches,
refresh_from_db) unchanged.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mohamedelabbas1996 will you make it extra clear in the docstrings that the permissions are managed under the project model? If we had an "Organization" model they may go there instead, but currently all permissions are based on a users association to a project. This PR introduces some actions that happen outside of an existing project (e.g. creating a new project), but there is not a another appropriate place to put those. So we are keeping them on the Project class level, a project instance is not required to make these checks.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

@mihow
Copy link
Collaborator

mihow commented Oct 31, 2025

Notes from our call:

  • Add instructions about adding model permissions
  • Rename the m2m permission descriptions create_MODEL_globally e.g. create_taxon_globally, create_processing_service_globally
  • Add default permissions to authorized user: create processing service, register pipelines, create taxon, assign tags to taxon
  • Move permission functions on the BaseModel to a mixin (PermissionsMixin) in permissions.py
  • Review the CodeRabbit review comments. Click Resolve on all reviewed ones.
  • Make a new ticket for managing permissions for the m2m project associations. A user should be able to add a Processing Service and Taxon to their project, but not make a shared global P.S. or Taxon. Creating the global m2m objects are model level permissions. The assignment or enabling of a P.S. or Taxon to a project is an object level permission.
  • Make the focus of this PR on getting create_project working well
  • Think about where to put developer documentation for permissions & roles. Remember to document how to add a custom view / API action. All new actions will return permission denied by default.

@coderabbitai
Copy link

coderabbitai bot commented Oct 31, 2025

Note

Docstrings generation - SUCCESS
Generated docstrings for this pull request at #1027

coderabbitai bot added a commit that referenced this pull request Oct 31, 2025
Docstrings generation was requested by @mihow.

* #1010 (comment)

The following files were modified:

* `ami/base/models.py`
* `ami/base/permissions.py`
* `ami/base/serializers.py`
* `ami/jobs/models.py`
* `ami/jobs/views.py`
* `ami/main/api/views.py`
* `ami/main/migrations/0079_assign_authorized_user_group.py`
* `ami/main/models.py`
* `ami/main/tests.py`
* `ami/ml/tests.py`
* `ami/ml/views.py`
* `ami/users/roles.py`
* `ami/users/signals.py`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enable all users to create projects

3 participants